feat(aws): WIF-only backplanes with a shared OIDC provider, enforced by the scorecard - #293
Conversation
AWS registers one OIDC provider per issuer URL per account, so the meshStack runner's issuer can be registered only once in an account regardless of how many building block backplanes federate through it. It is therefore platform infrastructure, not something a building block owns. This module is that provider on its own, applied once per AWS account that hosts backplanes. It takes no inputs — issuer, audience and thumbprint come from `data.meshstack_integrations` — so a platform team supplies only the two provider configurations and passes the `arn` output to every backplane in the account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pattern A had every backplane create its own `aws_iam_openid_connect_provider` with a `create_oidc_provider` toggle to opt out. That makes the first backplane deployed into an account the de-facto owner of shared infrastructure: the second one fails with EntityAlreadyExists unless somebody remembers the toggle, and destroying the owner breaks every other backplane federating through the same issuer. Pattern A now takes the provider's ARN as a required input, and 'The shared OIDC provider' is the section that says why, which module owns it, and how to migrate an account whose provider still sits in a backplane's state (a `removed` block with `destroy = false`, then an import — no destroy). It also records why this is not the same repetition Azure and GCP carry: a federated identity credential is a child of its UAMI and a workload identity pool is a named per-project resource, so those modules can each own theirs and none can collide. Only AWS has an account-level singleton keyed by the issuer URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vider The backplane offered two credential paths: workload identity federation, or an IAM user with an `aws_iam_access_key` when `workload_identity_federation` was left null. Nothing was ever on the null branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and internal-cloudfoundation's production deployment is federated — so the IAM user, its policy attachment and its access key were dead code behind a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the literal string "N/A; workload identity federation in use". `workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS account. That removes the provider resource, the `data` lookup that mirrored it, the `create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is unchanged — it still scopes `:sub` to this building block definition's subjects. The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed. Two migration blocks make this a no-op for a deployment already on the federated path: - `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it the next apply would destroy and recreate a live IAM role. - `removed` with `destroy = false` drops the OIDC provider from this backplane's state without deleting it out from under every other backplane in the account. Import it into the root that applies `modules/aws/oidc-provider` instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DC provider The backplane offered two credential paths: workload identity federation, or an IAM user with an `aws_iam_access_key` when `workload_identity_federation` was left null. Nothing was ever on the null branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and internal-cloudfoundation's production deployment is federated — so the IAM user, its policy attachment and its access key were dead code behind a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the literal string "N/A; workload identity federation in use". `workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS account. That removes the provider resource, the `data` lookup that mirrored it, the `create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is unchanged — it still scopes `:sub` to this building block definition's subjects. The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed. Two migration blocks make this a no-op for a deployment already on the federated path: - `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it the next apply would destroy and recreate a live IAM role. - `removed` with `destroy = false` drops the OIDC provider from this backplane's state without deleting it out from under every other backplane in the account. Import it into the root that applies `modules/aws/oidc-provider` instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same conversion as the two route53 backplanes — the IAM user, its policy attachment and its `aws_iam_access_key` were dead code on a null branch the integration never takes, `workload_identity_federation` becomes `nullable = false`, and `oidc_provider_arn` replaces a self-created OIDC provider — plus two conventions this module never had: - **No `create_oidc_provider` equivalent existed**, so the backplane always created its own provider and a second backplane in the same AWS account failed its apply with EntityAlreadyExists. It now takes the ARN like the others. - **The role ARN output was named `workload_identity_federation_role_arn`**; the convention is `workload_identity_federation_role`. Renamed, with the BBD's `AWS_ROLE_ARN` input updated. `moved` blocks carry the role and its policy attachment across the removed `count`, and a `removed` block with `destroy = false` drops the OIDC provider from state without deleting it. The policy keeps its federated-path name so no live IAM policy is renamed. Not changed: this integration still takes a hand-supplied `variable "workload_identity"` instead of reading `data.meshstack_integrations` the way the route53 modules do. That is a separate input-contract change for its consumers and no scorecard check covers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Azure, GCP and STACKIT backplanes have their identity conventions enforced by the scorecard. AWS had none, even though `.agents/references/aws-backplane.md` documents them — which is how three AWS backplanes could carry a nullable-workload-identity-federation-with-an-access-key-fallback and score 100%. Adds an `AWS Backplane` category: ten checks over what that reference documents. **AWS documents two legitimate patterns, so the checks are pattern-scoped.** Pattern A (external OIDC provider + IAM role) is for a building block acting in a single account; Pattern B (IAM user + CloudFormation StackSet) is for org-wide building blocks that must reach every account in an OU, and it mints an `aws_iam_access_key` on purpose. A blanket "no access key" check would therefore be wrong. Each check declares its pattern and reports `➖` for the other, the way `terraform_version` already reports `➖` for `manual` implementations. A backplane carrying federation machinery classifies as Pattern A *even when it also mints a key*: that hybrid is exactly the fallback the reference's first "What to Avoid" bullet forbids, and `aws_wif_no_access_key` reports it. | Check | Pattern | Enforces | |---|---|---| | `aws_wif_external_oidc_provider` | A | creates no OIDC provider; takes a required non-nullable `oidc_provider_arn` | | `aws_oidc_provider_notice` | A | the fixed two-line notice on `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` | | `aws_wif_no_access_key` | A | no `aws_iam_access_key` — the Azure `no_app_password` analogue | | `aws_wif_nonnullable` | A | `nullable = false`, no `default = null` fallback | | `aws_wif_subject_condition` | A | the `:sub` condition uses `var.workload_identity_federation.subjects` | | `aws_wif_role_output` | A | `workload_identity_federation_role`, ARN constructed not read off the resource | | `aws_wif_integration_env` | A | integration wires `AWS_ROLE_ARN` + `AWS_WEB_IDENTITY_TOKEN_FILE` | | `aws_cross_account_provider_aliases` | B | `configuration_aliases` declares `aws.management` and `aws.backplane` | | `aws_stackset_auto_deployment` | B | `SERVICE_MANAGED`, auto-deploying, `retain_stacks_on_account_removal = false`, `administration_role_arn` ignored | | `aws_cross_account_outputs` | B | `aws_access_key_id`, sensitive `aws_secret_access_key`, `role_name` | `aws_oidc_provider_notice` exists because that notice is the only signpost a first-time platform engineer gets. AWS has no plural OIDC-provider data source, so a missing provider cannot be turned into a friendly precondition; and terraform-docs runs only on `backplane/` and `buildingblock/`, so a comment in `meshstack_integration.tf` is rendered nowhere. Linting the copy-pasted notice is what keeps the pointer to the instructions alive. Each check was exercised against deliberately non-compliant fixtures as well as compliant ones — one pair per pattern, plus a backplane matching neither — so none is a vacuous pass, and every branch of every detail message was driven individually. The fixtures are deleted. Two judgement calls worth recording: - `role_name` is required only of StackSet-based Pattern B backplanes. The reference ties that output to "the IAM role deployed by StackSet to each target account"; `aws/opt-in-region` reaches a single management account through a role it creates itself and has no such name to publish. Requiring it there would be inventing a convention rather than enforcing one. - The category does not apply to a `backplane/` holding no `.tf` files of its own. `aws/agentic-coding-sandbox` is a composition whose backplane README says it "does not need any dedicated backplane", keeping only a `landingzone/` submodule there — it declares no automation principal to judge. `aws-backplane.md` joins `REF_FILES` and carries the `scorecard-checks` markers, so `--fix` links the section explaining each fix. Its two `### Implementation Pattern` headings became `(WIF)` and `(Cross-Account)` — they collided on one anchor. One supporting change: a category whose checks are all pattern-scoped can mark every one of them not applicable, leaving no score to render. The per-category table now prints `—` for that instead of `null%`. No module hits it today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First e2e test for any AWS hub module. Build-from-source mode provisions an ephemeral backplane in the smoke-test AWS account, builds the BBD from the branch under test, orders a workspace-level building block, and asserts on its outputs — the bucket ARN in particular, since that is what proves the bucket was created by the federated backplane role rather than the name being echoed back. Modelled on `gcp/storage-bucket/e2e`, with two AWS-specific details: - `aws_oidc_provider_arn` comes from `fixtures.aws.oidc_provider_arn`. The harness owns the account's OIDC provider, so the backplane is handed the ARN rather than creating one per run — which also keeps parallel AWS e2e cases from contending over an account-level singleton. - The integration takes `workload_identity` as a hand-supplied object rather than reading `data.meshstack_integrations` itself, so the e2e module reads the data source and derives the subject namespace prefix from the replicator's own subject. `provider "aws"` pins `allowed_account_ids` to the fixture account, so a wrong local session errors instead of creating IAM roles in someone else's account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scorecard Check
Warning: module "aws/oidc-provider" not found — skipping. 📊 meshstack-hub Module Scorecard
📋 Per-Module Category SummaryScore per category per building block.
Core Structure — ✅ all passingBasic module file structure and documentation — applies to 3 modules
Core Structure — Summary
Integration — ✅ all passingmeshstack_integration.tf conventions — applies to 3 modules
Integration — Summary
AWS Backplane — ✅ all passingAWS automation principal conventions (WIF or cross-account StackSet) — applies to 3 modules
AWS Backplane — Summary
Azure Backplane — not applicableAzure UAMI-based automation principal conventions — applies to 0 modules No applicable modules. GCP Backplane — not applicableGCP workload-identity-federation automation principal conventions — applies to 0 modules No applicable modules. STACKIT Backplane — not applicableSTACKIT WIF-based automation principal conventions — applies to 0 modules No applicable modules. Testing — some checks failingEnd-to-end test coverage — applies to 3 modules
Testing — Summary
📈 Overall SummaryOverall Average Score: 95%Score Distribution
|
|
This pull request is automatically being deployed by Amplify Hosting (learn more). |
Replaces #286 and #287, squashed onto current
mainwith the intermediate states dropped — thecreate_oidc_providertoggle that those two added and then removed never appears.Three AWS backplanes (
s3_bucket,route53-dns-record,route53-dns-alias-record) carried aworkload_identity_federationvariable defaulting tonull, withcount = var.workload_identity_federation == null ? 1 : 0selecting an IAM user and anaws_iam_access_keyon the null branch..agents/references/aws-backplane.mdalready forbade that shape and nothing enforced it. They also each created their own OIDC provider, which AWS only allows one of per issuer URL per account.This does both: drops the dead credential path, and moves the OIDC provider out to a module of its own — then adds the scorecard category that keeps it that way.
Commits
feat(aws/oidc-provider)docs(aws)refactor(aws/route53-dns-record)refactor(aws/route53-dns-alias-record)refactor(aws/s3_bucket)feat(scorecard)AWS Backplanecategory, ten pattern-scoped checkstest(aws/s3_bucket)Why the provider is externalized
AWS registers one OIDC provider per issuer URL per account. The meshStack runner has one issuer, so an account has room for exactly one no matter how many backplanes federate through it. A backplane that creates its own is claiming shared infrastructure: the second backplane in the account fails with
EntityAlreadyExists, and destroying whichever owns it breaks every other one.This is where AWS genuinely differs from the others, and why the repetition is not the same repetition: an Azure federated identity credential is a child of its UAMI and a GCP workload identity pool is a named per-project resource, so each module can own its own and none can collide. Only AWS has an account-level singleton keyed by the issuer URL.
Backplanes therefore take
oidc_provider_arnand create nothing — losing the provider resource, thedatalookup that mirrored it and thetry()local that picked between them. The description is a fixed two-line notice, copied verbatim into the integrations'aws_oidc_provider_arn:aws_oidc_provider_noticelints it, because that notice is the only signpost a first-time platform engineer gets: AWS has no plural OIDC-provider data source, so a missing provider cannot be turned into a friendly precondition, and terraform-docs runs only onbackplane/andbuildingblock/, so a comment inmeshstack_integration.tfis rendered nowhere.Migration is a forget plus an import
No destroy anywhere. Each backplane ships both:
For
internal-cloudfoundation's meshcloud-prod that is one forget and one import:dnsowns the provider today anddns-aliasalready passedcreate_oidc_provider = false. A deployment still on the access-key path must start passingworkload_identity_federation; its next apply destroys the IAM user and revokes the key, which is the intended migration.Verification
The e2e test passes against a live AWS account. Run 32849844853 —
hub:aws/s3_bucket,1 passed, 0 failed. The AWS content of this branch is byte-identical to the commit that run tested. It proves what static analysis cannot: no static credential exists anywhere, the backplane created no OIDC provider and federated through the account's shared one, and the claims are right — a wrong audience fails the STS exchange and a wrong subject the trust policy, yet the bucket exists and the assertions check its ARN.Also green:
tofu validateon all three backplanes, on each integration root wired against its local backplane, and on the e2e module with its whole chain resolved; the full pre-commit suite, which is what CI runs; and every AWS backplane at 🟢 100% on the new category. Full scorecard diffed againstmain— no pre-existing category verdict moved, 53 modules both sides.Each of the ten checks was exercised against deliberately non-compliant fixtures as well as compliant ones — one pair per pattern, plus a backplane matching neither — so none is a vacuous pass. The fixtures are deleted.
Not included
aws/s3_bucketsits at 🟡 75% Testing becausemain's newno_buildingblock_tftestcheck flags itsbuildingblock/terragrunt.tftest.hcl. Folding that into the e2e suite is not quite possible yet — it asserts ontags, which the BBD exposes no input for — so it stays inmain's own migration backlog rather than being deleted with its coverage unreplaced.🤖 Generated with Claude Code